或許這是您一直期望說明的環節 - 關於如何切換頁面。行動裝置應用程式通常會被組織成多個頁面。你使用過的許多應用程式應該都使用這樣的設計。例如一個應用首先呈現的是一個電影列表,當你選擇其中一個項目的時候切換到另一個頁面顯示更多資訊。這就是由一個頁面導向另一個頁面。
在 Flutter ,在不同頁面之間切換叫做路由。由 Navigator
組件管理。Navigator
組件管理導航堆疊(Navigation Stack) 概念上類似圖層的堆疊進而決定那個畫面在前,那個在背景。Navigator
推送一個新路由到堆疊中或移除之前的路由。在這個章節,我們將學習如何使用 Navigator
管理應用程式的路由,以及如何加入轉場動畫,如何在不同頁面之間傳遞資料。將會含蓋下列主題:
Navigator
因為螢幕的關係,幾乎所有行動應用程式都超過一個頁面。如果你是 Android 或 iOS 開發者,你大概已經知道如何使用 Activity
或 ViewController
負責控制呈現畫面。
而在 Flutter 中要切換頁面最重要的就是 Navigator
組件。它的任務就是管理這些畫面的切換也就是負責維護頁面的歷史記錄,讓使用者可以回到上一頁等操作
一個頁面在 Flutter 中就是一個新組件,它會被疊在目前的組件之上。這就是路由管理的概念,路由定義了可以造訪的頁面。沒錯這個類別就是 Route
,我們就是用它來協助達成整個瀏覽換頁。
簡單說主要的類別包含:
Navigator
: 負責管理路由 Route
,是 Flutter 中的路由管理器負責管理路由堆疊,控制頁面的導航和切換。Overlay
:Navigator
用來指定該顯示的路由。Overlay
是一個特殊組件,位於整個應用程式的最頂層。可以把它想像成一個透明的畫布,覆蓋在整個應用程式上層。當你導航到一個新的畫面時,Navigator
會在 Overlay
上建立一個新的 OverlayEntry
,並將新的畫面的組件加入。Route
:導航的端點,可以想成一個頁面。我們將學習這些類別,但首先,我們需要了解隨著 Flutter 的發展,切換頁面的具體作法是如何演進。
隨著 Flutter 擴展到網頁應用程式領域,演化成一個更加完整的開發框架。在頁面之間切換導航的方式也發生了改變。現在共有 2 種不同的導航方式。Navigator 1.0 採用命令式的風格,通過程式指示框架從堆疊中新增或移除頁面。這種方式適用於大多數情境。尤其對於 iOS 和 Andriod 開發者來說相對簡單易懂。
然而加入網頁的支援也帶來了新的挑戰,例如通過一個 URL 可以直接切換到應用程式流程深處的某個頁面。在 iOS 或 Android,通常會預期使用者從第一個頁面進入,然後從那個位置開始切換頁面,導航到其他頁面。但是在網頁上,你直接分享一個連結就可以直接到特定頁面。例如我們正在瀏覽一個書店網站,然後發現並分享某本書的連結,通常我們希望使用者可以直接造訪該頁面,同時又能夠使用原本流程會產生的堆疊效果,在這個例子就是希望上一頁可以回到列表頁。
這並不是網頁特有的情境,iOS 或 Android 的 Deep Link 也屬於這種情境。只是在網頁中這種情況比較頻繁且明顯,使用者可以進入應用程式的任何頁面,但使用 Navigator 1.0 不太適合處理這種情境。
Navigator 2.0 採用一種宣告式的風格,類似組件結構的撰寫方式。支援的頁面預先宣告定義,而狀態決定該呈現那個頁面。後續我們將會探討這兩種方式,因為它們都是可使用的方式。
許多生態圈社群的成員認為這樣的命名不太好,因為基於一些慣例,這表示 2.0 是一種取代 1.0 的方式。
但讀到這你應該知道這不正確,此外 2.0 相較之下比較複雜。甚至還有一些套件是用來簡化 2.0 的,例如 Flutter 官方文件中特別提到 go_router 。
命令式 Imperative vs 宣告式 Declarative
命令式和宣告式是撰寫程式碼的風格,各有適用的情境。命令式就像是下指令一樣,例如你使用 Navigator 1.0 直接
push
一個頁面。而宣告式的作法則是先定義好什麼狀態會對應什麼行為,例如 Flutter 的組件在構建 UI 時就是一種宣告式風格。
Navigator
組件是讓使用者從頁面 A 切換到頁面 B 的關鍵。另外大部分情況,使用者在切換頁面時也需要將資料帶到新頁面,這是 Navigator
另一個重要任務。
從概念上來說,Flutter 中的導航其實是一系列畫面的堆疊:
Navigator
中,堆疊最上層的元素就是目前應用程式顯示的畫面Navigator
有 push()
和 pop()
兩個方法來處理新增/移除堆疊中的畫面。這就是 1.0 的使用方式。Navigator
組件有個 pages
屬性,類似堆疊裡面的畫面組件清單,而畫面的呈現,移除,都是基於組件的狀態來決定。這是屬於 2.0 的方式。1.0 的方式從 Flutter 建立以來就開始使用,絕大多數的程式都可以使用 Navigator 1.0 的方法切換畫面。因此了解這種方式很重要,在許多情況下將會是比較適合的方式而且也比較容易。
首先我們需要先了解 Route
前面提到堆疊中的元素,其實就是 Route
。在 Flutter 中定義它們有多種方式。每當我們希望導航到一個新畫面,我們就需要定義一個新的 Route
組件還有一些屬性通過 RouteSettings
加入。
這是一個單純的類別包含 Route
相關的資訊
MaterialPageRoute(
builder: (context) => Screen(),
settings: RouteSettings(
name: '/new-screen',
arguments: '這裡傳遞資料到新頁面',
),
),
name
路由的唯一識別值,下個章節會詳細說明arguments
傳遞資訊到目標路由Route
是高階抽象的類別,應用不同的平台畫面預期的行為可能不一樣。因此在 Flutter 中,為了符合平台預期的行為支援了 2 種實作分別是 MaterialPageRoute
和 CupertinoPageRoute
分別對應 Android 和 iOS。
因此,當你開發應用程式的時候必須決定要使用 Material Design 還是 iOS 或者根據情境兩者都支援。
有了上面基礎概念,接著我們可以來看看如何使用 Navigator
組件實作。回到 Hello World 專案,首先讓我們先在 main.dart
來建立第二個畫面的組件:
class DestinationDetails extends StatelessWidget {
DestinationDetails({ required this.title });
final String title;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: ElevatedButton(
child: Text('返回'),
onPressed: () {
// TODO
}
),
),
);
}
}
這是一個簡單的 Stateless
組件,可以傳入 title
參數。然後定義了 build
方法。
Scaffold
組件屬於結構上層的組件可以指定 AppBar
顯示在螢幕上方,body
則為主要內容區塊,還支援其他如浮動按鈕等。作為 Material Design 組件意味著可以搭配其他 Material 組件。
上面已經使用了基本的 AppBar
:
appBar: AppBar(title: Text(title)),
然後 Center
組件單純負責置中在其內部的組件。最後,我們加入 ElevatedButton
組件,這裡就是後面我們要加入導航功能的地方。
現在,讓我們加入路由支援導航到新頁面。在一個基本專案的 _MyHomePageState
中找到 Column
組件,我們修改它的 children
children: [
// ...
ElevatedButton(
child: Text('跳轉'),
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (context) {
return DestinationDetails(title: "標題");
}),
);
},
),
]
跟之前的 ElevatedButton
類似,也是設定了 child
和 onPressed
。但這次我們在 onPressed
使用了:
Navigator.of(context).push()
你可能注意到這個機制和之前學習的 InhertitedWidget
類似。,Navigator
本身利用了 InheritedWidget
的特性,搭配 GlobalKey ,表示我們可以使用 Navigator.of(context)
找到最近的 Navigator
物件實例。
實際上,在 MaterialApp
組件內部隱式建立了 Navigator
組件,因此我們可以通過 MaterialApp
取得。所以這個過程就是向上查找樹狀結構找到與 MaterialApp
組件關聯的 Navigator
。
然後我們 push()
,如我們之前學習的,Navigator
組件是一個堆疊,我們把新畫面加入堆疊,然後它就是顯示。而 push()
的參數是 MaterialPageRoute
:
MaterialPageRoute(builder: (context) { ... })
MaterialPageRoute
繼承了 Route
類別。它將 Material Design 的效果加入到 Route
中。路由負責儲存新畫面相關的資訊,然後關於新畫面的呈現由 builder
參數處理。
最後我們在 builder
中返回新的組件:
return DestinationDetails(title: '標題');
意思是路由會使用 builder
來構建畫面,而我們希望顯示的是 DestinationDetails
組件。
有了這些設定,我們現在可以嘗試跳轉到新畫面了。但是我們還需要加入返回按鈕的程式碼:
onPressed() {
Navigator.of(context).pop();
}
這次我們使用 pop()
方法來從堆疊中移除當前頁面,從而返回到上一個頁面。
到此,我們完成了第一個導航範例。現在我們了解了如何建構多個頁面的應用程式,雖然初學看起來很複雜,但它們對應開發應用是非常有幫助的。
你可能看過範例使用的 Navigator
有點不太一樣。它們使用了 Navigator.pop(context)
取代 Navigator.of(context).pop()
。這兩種方法實際上是等價的,因為 Navigator.pop
方法的第一行程式碼就是使用 context
找到 Navigator
組件。兩種方式可以隨您的偏好使用。
路由名稱是導航很重要的一部分。用於識別路由和其 Navigator
組件。我們可以定義一系列路並為每個路由關聯一個名稱,為路由和對應的畫面提供一個意義抽象。後續還可以使用路徑的結構來切換頁面,簡單說你可以把它們視為類似 URL 的使用方式。
前面的例子很簡單,我們概略的理解了路由的使用。除此之外,在實務上通常我們會利用具名路由來組織路由結構,它讓我們可以達成:
具名路由在 MaterialApp
組件中指定,接著讓我們來學習如何使用具名路由。
首先,在 MaterialApp
組件中定義 routes
參數
routes: {
'/': (context) => MyHomePage(title: '首頁'),
'/destination': (context) => DestinationDetails(title: '詳細頁面'),
}
在上面程式中,我們設定了兩個路由,/
路徑對應 MyHomePage
,/destination
對應 DestinationDetails
組件。如果你現在嘗試執行程式碼,那麼你會遭遇錯誤。這是因為同時使用了 /
和 home
參數。/
是一個特殊的路由等同於 home
屬性,這表示我們定義了首頁 2 次,但 Flutter 不知道該使用那個。
移除 home
參數設定,即可執行應用程式。而稍早我們建立的命令式路由依舊可以執行,因為我們依舊將路由加入了堆疊之中。現在我們來調整剛剛 _MyHomePageState
的範例:
onPressed: () {
Navigator.of(context).pushNamed('/destination');
}
相較於直接使用 Route
這種方式更加清楚。
你可能注意到 DestinationDetails
的 title
參數是固定的,而真實情況不太可能這樣;我們希望頁面能夠設定自己的參數。
為了解決這個問題,pushNamed
方法支援傳入參數到路由:
Navigator.of(context).pushNamed('/destination', arguments: "詳細頁面");
然而,傳遞參數會導致路由的設定增加複雜度,我們無法在使用 MaterialApp
的 routes
參數,取而代之,我們必須使用 onGenerateRoute
參數來傳遞設定到 DestinationDetails
組件。onGenerateRoute
可以完全控制目標畫面。
「在 MaterialApp
移除 routes
,新增 onGenerateRoute
」
onGenerateRoute: (settings) {
if (settings.name == '/') {
return MaterialPageRoute(
builder: (context) => MyHomePage(title: "首頁")
);
} else if (settings.name == '/destination') {
return MaterialPageRoute(
builder: (context) => DestinationDetails(title: settings.arguments as String)
);
}
}
onGenerateRoute
基於 settings.name
來決定回傳的路由,同時也可以搭配其他參數。看起來比較類似原本的 push
用法,但有個明顯的缺點就是 settings.arguments
少了型別安全,並且必須轉換型別如上面 as String
。
這裡沒有所謂正確答案,使用何種方式取決於個人偏好和專案的需求。 push()
方法可以支援型別安全的參數。而具名路由可以集中管理。
到此我們已經了解如何使用 push
和 pushNamed
切換畫面,以及它們如何傳遞參數。但還有一種情況,就是我們希望在 pop()
的時候將結果回傳到之前的頁面。舉例來說,我們的首頁有一個「選擇送貨地址」的按鈕,點擊之後進到下一個頁面選擇相關地址,當選擇完畢,使用者點擊了「確認」回到首頁。這種情況,首頁必須得知道使用者選擇了哪個地址。
這個回到首頁,就是 pop()
時希望將結果回傳。
當路由被推送,我們可能會希望從中取回某些資訊,例如在新頁面中我們讓使用者輸入的資料,我們可以通過 pop()
的 result
參數取得回傳資料。
push()
方法和其它類似的方法會回傳 Future
。這個 Future
在路由 pop()
的時候會 resolve
,而 Future
的值就是 pop()
的 result
參數。
我們已經看過了在 push()
的時候加入參數傳到新的路由。反過來當 pop()
的時候也可以。讓我們更新詳細頁面的組件的 build
:
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
child: Text('加入最愛'),
onPressed: () {
Navigator.of(context).pop(true);
}
),
ElevatedButton(
child: Text('關閉'),
onPressed: () {
Navigator.of(context).pop(false);
}
),
],
),
),
);
}
注意到除了一些組件結構的調整加入 ElevatedButton
,現在 pop()
也帶入參數。在這個範例,當使用者將項目加入最愛的時候回傳布林值,不過 pop()
可以使用任何型別。
然後我們接著更新 MyHomePage
的 ElevatedButton
使其可以接收回傳的值。
ElevatedButton(
child: Text('詳細頁面'),
onPressed: () async {
// 重點
bool? outcome = await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) {
return DestinationDetails(title: '詳細頁面');
}
),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("$outcome"))
);
}
)
push
回傳的結果是一個 Future
,因此我們需要使用 await
等待結果處理完成。這也表示我們需要更新方法為匿名函式的格式,所以 onPressed
的函式加上了 async
。
最後我們使用很常見的 SnackBar
顯示回傳的資訊。ScaffoldMessenger
前面提到是一個 InheritedWidget
因此也可以使用 .of
方法搜尋到物件,後續 showSnackBar
方法單純傳入 SnackBar
作為參數。
如你所見, Navigator 1.0 功能齊全且直覺易用,可以輕易的為你的應用加入導航功能。基本上可以應付大多數的需求,但如果你希望你的應用程式也支援網頁的話,那麼 Navigator 1.0 可能無法完全滿足需求。
如同之前提到的, Navigator 1.0 在處理深層連結時有一些限制,舉例來說連結希望直接進入詳細頁面,但又希望堆疊可以包含列表讓使用者點擊返回的時候是回到列表頁。因此 Navigator 2.0 產生了。它採用了不同的宣告式方式來處理路由。
Navigator
有一個叫 pages
的參數,可以接受一系列 Page
組件,當狀態發生改變,這個列表也會發生變化,進一步路由堆疊會更新以符合 pages
。
這和其他由子組件組成的列表組件的運作方式非常類似。例如 Column
組件,狀態改變導致其子組件發生變化時,Flutter 將會重新渲染畫面以反映這些變化。此方式最大的優點就是如果我們先設計好狀態,那麼與之相關的多個畫面堆疊可以自動基於狀態自行執行對應行為。如果是之前 1.0 的機制,我們可能會需要自己處理狀態的傳遞,然後自行根據狀態決定路由堆疊。相較之下 2.0 通過宣告的方式讓 Navigator 自動處理路由。
一般來說 2.0 被認為是相對複雜的導航方式,這個章節不會過於深入探討,但了解其如何運作對於後續開發還是很有幫助。一旦你更能掌握 Flutter,你可以更全面的深入學習這種方式。在這個範例,我們依舊使用 Hello World 範例,但是採用 2.0 的方式。
讓我們直接來看看完整的範例:
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
String? _selectedDestination;
void _setDestination(String destination) {
setState(() {
_selectedDestination = destination;
});
}
final GlobalKey<ScaffoldMessengerState> snackBarKey =
GlobalKey<ScaffoldMessengerState>();
@override
Widget build(BuildContext context) {
return MaterialApp(
scaffoldMessengerKey: snackBarKey,
title: '路由範例',
home: Navigator(
pages: [
MaterialPage(
child: MyHomePage(
title: '首頁',
destinationCallback: _setDestination,
),
),
if (_selectedDestination != null)
MaterialPage(
child: DestinationDetails(destination: _selectedDestination!),
),
],
onPopPage: (route, result) {
if (!route.didPop(result)) {
return false;
}
if (result && _selectedDestination != null) {
snackBarKey.currentState?.showSnackBar(
SnackBar(
content: Text("加入 $_selectedDestination 到最愛清單"),
action: SnackBarAction(
label: '關閉',
onPressed: () {
snackBarKey.currentState?.hideCurrentSnackBar();
},
),
),
);
}
setState(() {
_selectedDestination = null;
});
return true;
}),
);
}
}
class MyHomePage extends StatelessWidget {
const MyHomePage(
{super.key, required this.title, required this.destinationCallback});
final String title;
final void Function(String) destinationCallback;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
destinationCallback('項目 1');
},
child: const Text('項目 1'),
),
ElevatedButton(
onPressed: () {
destinationCallback('項目 2');
},
child: const Text('項目 2'),
),
],
),
),
);
}
}
class DestinationDetails extends StatelessWidget {
const DestinationDetails({super.key, required this.destination});
final String destination;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(destination)),
body: Center(
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
ElevatedButton(
child: const Text('加入最愛'),
onPressed: () {
Navigator.of(context).pop(true);
},
),
ElevatedButton(
child: const Text("返回"),
onPressed: () {
Navigator.of(context).pop(false);
},
),
]),
),
);
}
}
首先注意到我們將 MyApp
修改為 StatefulWidget
,這是因為 2.0 使用狀態來決定路由。接著我們加入 _selectedDestination
。
然後我們跳到 DestinationDetails
組件一樣使用 1.0 的 pop()
方法。但在 Navigator
中 1.0 和 2.0 的 pop()
作用非常不同。
接下來因為狀態在 MyApp
中而不是 MyHomePage
,當目標選擇的時候,在 MyHomePage
的按鈕需要更新 MyApp
的狀態,也就是將 _selectedDestination
換成選擇的項目。
為了達成這個目的,我們在 MyApp
加入了 callback 方法,並傳入 MyHomePage
,該方法單純使用 destination
作為參數,並使用 MyHomePage
提供的值來變更狀態。
接著因為我們需要在 MyHomePage
中使用這個方法,因此將其作為參數傳入。
const MyHomePage(
{super.key, required this.title, required this.destinationCallback});
final String title;
final void Function(String) destinationCallback;
後續我們就可以使用這個方法了。最後我們需要關注的是 Navigator
的。pages
和 onPopPage
。
首先是 pages
參數,其包含了兩個畫面的列表,一個是 MyHomePage
組件,一個是 DestinationDetails
。在這個 pages
參數中會決定畫面,也就是當 _selectedDestination
不為空的時候顯示 DestinationDetails
,因此當 _setDestination
觸發狀態變更,目標狀態不再為空並重新渲染的時候, DestinationDetails
就會呈現在畫面上。
接著,是 onPopPage
。在這個方法,我們告訴 Navigator
當有頁面 pop
的時候需要做什麼。在這個方法,我們首先使用 .didPop()
檢查 pop()
是否完成。在這個範例 DestinationDetails
觸發了 pop()
通過將 _selectedDestination
設為 null ,頁面將回到 MyHomePage
。
除了內建的 Navigator 2.0 ,還有許多簡化 2.0 的套件,例如 go_router
和 AutoRoute
。它們不只簡化了 Navigator 2.0 還加入了許多好用的功能,例如型別檢查,路由防護限制存取畫面,例如只有在使用者登入的情況才可存取畫面。
為了在設計專案架構的時候有更多知識可以去評估,這裡我們簡單介紹如何使用 Go Router 套件在 Flutter 應用程式中實現基本的頁面導航。
安裝套件:
$ flutter pub add go_router
讓我們直接上範例:
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
void main() => runApp(MyApp());
final GoRouter _router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => HomePage(),
),
GoRoute(
path: '/about',
builder: (context, state) => AboutPage(),
),
],
);
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _router,
title: 'Go Router 範例',
);
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('首頁')),
body: Center(
child: ElevatedButton(
child: Text('前往關於頁面'),
onPressed: () => context.go('/about'),
),
),
);
}
}
class AboutPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('關於')),
body: Center(
child: ElevatedButton(
child: Text('返回首頁'),
onPressed: () => context.go('/'),
),
),
);
}
}
首先,我們需要設定 Go Router。在 main.dart
文件中,我們定義了路由配置。這裡我們定義了兩個路由:一個是根路徑 /
,對應 HomePage
;另一個是 /about
,對應 AboutPage
。
接下來,我們需要在 MaterialApp 中使用 Go Router
在 HomePage 和 AboutPage 中,我們使用 context.go()
方法來實現頁面跳轉
在 AboutPage 中,我們使用相同的方法返回首頁
通過這個簡單的範例,我們展示了如何使用 Go Router 在 Flutter 應用程式中實現基本的頁面導航。Go Router 提供了一種簡潔而強大的方式來管理應用程式的路由,使得頁面之間的跳轉變得更加容易和靈活。
除了 Go Router ,目前主流的導航解決方案還有 AutoRoute 和 Beamer 這兩者內建就支援路由保護機制(Guards)。